"use client"; import { useState } from "react"; import Link from "next/link"; import type { ValuationRow } from "@/lib/types"; import type { ToveliEstimate } from "@/lib/estimate"; import { fmtPrice, fmtPct, fmtDate, fmtInt, fmtArea } from "@/lib/format"; import { Tabs, Notice, Stat } from "@/components/ui"; import { PriceHistoryChart } from "@/components/price-chart"; import { MapView } from "@/components/map-view"; import { MortgageWidget } from "@/components/mortgage-widget"; import { EstimateBreakdown } from "@/components/estimate-breakdown"; import type { Insight, MarketPosition } from "@/lib/insights"; import Link2 from "next/link"; type L = { uid: string; market: string; price: number | null; description: string | null; features: string[]; details: Record; lat: number | null; lng: number | null; category: string; city: string | null; address: string | null; area_sqft: number | null; year_built: number | null; bedrooms: number | null; bathrooms: number | null; direct?: boolean }; type Fin = { municipalTax: number | null; schoolTax: number | null; assessment: number | null; condoFees: number | null }; type Budget = { rent: number; electricity: number | null; heatingIncluded: boolean; electricityIncluded: boolean; internet: number; total: number; notes: string[] } | null; type Score = { walk: number | null; transit: number | null; bike: number | null; calm: number | null; services: number | null; global: number | null; version: string; computed_at: string } | null; type Sale = { id: string; date: string; amount: number; street: string; city: string; property_type: string | null; year_built: number | null; floor_area: number | null }; type Prop = { id: string; role_value: number | null; role_land: number | null; role_building: number | null; key_kind: string; dedup_confidence: number | null; n_listings: number } | null; const SKIP_KEYS = new Set(["pieces", "img_audited", "transaction", "prix_pi2", "prix_m2", "inclusions", "appliances", "parking"]); export function ListingTabs({ listing: l, valuation, live, priceEvents, score, sales, property, insights = [], position, financials, budget }: { listing: L; valuation: ValuationRow | null; live: ToveliEstimate | null; priceEvents: { ts: string; price: number }[]; score: Score; sales: Sale[]; property: Prop; insights?: Insight[]; position?: MarketPosition; financials?: Fin; budget?: Budget }) { const rent = l.market === "rent"; const items = [{ id: "apercu", label: "Aperçu" }, { id: "prix", label: rent ? "Loyer et marché" : "Prix et marché" }, ...(!rent && live ? [{ id: "comparables", label: "Comparables" }] : []), ...(!rent ? [{ id: "budget", label: "Budget" }] : []), { id: "quartier", label: "Quartier" }]; const [tab, setTab] = useState("apercu"); const detailEntries = Object.entries(l.details).filter(([k, v]) => !SKIP_KEYS.has(k) && v != null && typeof v !== "object" && String(v).trim() !== "").slice(0, 40); const pieces = Array.isArray(l.details.pieces) ? (l.details.pieces as { nom?: string; niveau?: string; dimensions?: string; revetement?: string }[]) : []; const est = live?.result.estimate ?? valuation?.estimate ?? null; const method = valuation?.method_detail ? (JSON.parse(valuation.method_detail) as Record) : null; return (
{tab === "apercu" && (<> {insights.length > 0 &&

En bref

    {insights.map((i) =>
  • {i.text}
  • )}

Constats calculés à partir des données de l'annonce et du marché Toveli, sans interprétation éditoriale.

} {budget &&

Budget mensuel estimé

    {budget.notes.map((n) =>
  • {n}
  • )}
}

Description

{l.description ?

{l.description}

:

Aucune description fournie par la source.

}
{l.features.length > 0 &&

{rent ? "Inclusions et commodités" : "Caractéristiques"}

    {l.features.slice(0, 40).map((f) =>
  • •{f}
  • )}
} {detailEntries.length > 0 &&

Détails pratiques

{detailEntries.map(([k, v]) =>
{k.replace(/_/g, " ")}
{String(v)}
)}
} {pieces.length > 0 &&

Pièces

{pieces.slice(0, 30).map((p, i) => )}
PièceNiveauDimensionsRevêtement
{p.nom}{p.niveau}{p.dimensions}{p.revetement}
} )} {tab === "prix" && (<> {!rent && (live || valuation) ? (

Valeur estimée et écart avec le prix demandé

est * 1.05 ? "warn" : l.price < est * 0.95 ? "good" : "info") : undefined} />
{live &&
} {!live && method && (
{[["Modèle hédonique", method.model_est], ["Comparables ajustés", method.comps_est], ["Méthode du coût", method.cost_est], ["Rôle indexé (ratios IAAO)", method.role_est], ["Ensemble (médiane)", method.ens_est], ["Valeur au rôle", method.valeur_role]].filter(([, v]) => v).map(([k, v]) =>
{k}
{fmtPrice(Number(v))}
)}
)}
Méthode : {live?.method.name ?? "hybride Toveli (modèle hédonique + comparables ajustés)"} · version {live?.method.version ?? valuation?.model_version} · {live ? `calculée aujourd'hui` : `évaluée le ${fmtDate(valuation?.evaluated_at)}`}. {live?.method.disclaimer ?? "Estimation statistique indicative ; ne remplace pas une évaluation agréée (OEAQ)."}
{live && live.method.limits.length > 0 &&
    {live.method.limits.map((x) =>
  • {x}
  • )}
}
) : rent && valuation ? (

Loyer demandé et loyer de référence

Modèle de loyer de référence Toveli ({valuation.model_version}) : médianes par segment (ville/quartier × chambres), ajustements multiplicatifs, voisins géographiques. Indicatif seulement.
) : Aucune estimation disponible pour cette annonce ({l.category === "terrain" || l.category === "commercial" ? "catégorie non couverte par le modèle" : "données insuffisantes"}).} {position?.metric && (

Position dans le marché {position.metric.scope === "province" ? "québécois" : `· ${position.metric.scope_value}`}

{[["P25", position.metric.p25_price], ["Médiane", position.metric.median_price], ["P75", position.metric.p75_price]].map(([k, v], i) => {k} {fmtPrice(v as number, { compact: true, rent })})} {position.pricePercentile != null && Cette annonce · {position.pricePercentile}e centile}

{fmtInt(position.metric.n)} {l.category === "logement" ? "logements" : `${l.category}s`} {rent ? "à louer" : "à vendre"} comparés · {position.metric.new_30d ?? 0} nouvelles en 30 jours · {position.metric.median_days != null ? `${position.metric.median_days} jours médians en ligne` : ""}{position.ppsfVsMedian != null ? ` · prix/pi² ${fmtPct(position.ppsfVsMedian)} vs médiane` : ""}.

{l.city && Toutes les données de {l.city} →}
)} {financials && (financials.municipalTax || financials.schoolTax || financials.assessment || financials.condoFees) && (

Taxes, évaluation et charges

{financials.assessment ? : null}{financials.municipalTax ? : null}{financials.schoolTax ? : null}{financials.condoFees ? : null}

Montants déclarés par la source ou issus du rôle d'évaluation ; vérifiez auprès de la municipalité.

)}

Historique du prix demandé

{priceEvents.length >= 2 ? :

Aucun changement de prix observé depuis la première collecte{priceEvents[0] ? ` (${fmtDate(priceEvents[0].ts)})` : ""}.

} {priceEvents.length >= 2 &&
    {priceEvents.slice(-6).reverse().map((e, i, a) => { const prev = a[i + 1]; return
  1. {fmtDate(e.ts)} · {fmtPrice(e.price, { rent })}{prev ? ({fmtPct((e.price - prev.price) / prev.price)}) : " · première observation"}
  2. ; })}
}
{property?.role_value != null && (

Rôle d'évaluation municipal

Source : rôle d'évaluation foncière (MAMH, Données Québec, licence ouverte). La valeur au rôle reflète les conditions du marché à une date de référence antérieure, pas la valeur actuelle.

)} {l.city &&

Voir le marché de {l.city} →

} )} {tab === "comparables" && live && (

Ventes comparables ajustées

{live.result.nCompsUsed} ventes retenues parmi les candidates autour de la propriété, pondérées par distance, récence et similarité de superficie. Chaque ajustement est affiché en dollars.

{live.result.comps.map((c) => )}
VenteDatePrixMarchéSuperficieÂgeAjustéDist.Poids
{c.street ?? "Adresse non publiée"}
{c.city} · {c.propertyType ?? "type n/d"}{c.floorArea ? ` · ${Math.round(c.floorArea)} m²` : ""}{c.yearBuilt ? ` · ${c.yearBuilt}` : ""}
{fmtDate(c.date)}{fmtPrice(c.amount)}= 0 ? "text-good" : "text-warn"}`}>{c.adjTime >= 0 ? "+" : ""}{fmtPrice(c.adjTime)}= 0 ? "text-good" : "text-warn"}`}>{c.adjArea >= 0 ? "+" : ""}{fmtPrice(c.adjArea)}= 0 ? "text-good" : "text-warn"}`}>{c.adjAge >= 0 ? "+" : ""}{fmtPrice(c.adjAge)}{fmtPrice(c.adjustedPrice)}{c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`}{(c.weight * 100).toFixed(0)} %

Ajustements : marché = indice mensuel par type ; superficie = 50 % du $/m² du comparable (± 25 % max) ; âge = 0,5 %/an (± 10 % max). Médiane pondérée des prix ajustés → estimation « comparables ». Source des ventes : base de transactions Toveli (ventes ≥ 50 000 $).

)} {tab === "budget" && !rent && l.price && (

Budget et financement

)} {tab === "quartier" && (
{l.lat != null && l.lng != null &&
} {score ? (

Scores du secteur

{([["Global", score.global], ["Marche", score.walk], ["Transport", score.transit], ["Vélo", score.bike], ["Calme", score.calm], ["Services", score.services]] as const).map(([k, v]) =>

{v != null ? v.toFixed(1) : "—"}

{k}

)}

Scores de quartier Toveli ({score.version}) : densité de commerces, arrêts de transport, pistes cyclables, trafic et services dans un rayon de marche, à partir d'OpenStreetMap (© contributeurs OSM, ODbL) et du recensement 2021. Calculés le {fmtDate(score.computed_at)}.

) : Pas de score de quartier disponible pour ce secteur (zone non couverte par les tuiles calculées).} {sales.length > 0 && (

Ventes récentes à proximité

    {sales.map((s) =>
  • {s.street} · {s.city}{s.property_type ? ` · ${s.property_type}` : ""}{s.floor_area ? ` · ${Math.round(s.floor_area)} m²` : ""}{fmtPrice(s.amount)}
    {fmtDate(s.date)}
  • )}
)}
)}
); }